iT邦幫忙

2026 iThome 鐵人賽

DAY 29
0
AI Engineering

Harness Engineering × Pi Agent 實戰:打造可觀測、可評估的 AI Coding Agent系列 第 29 篇

Day29:不用重跑 160 次實驗:如何從 Session 事件重算成本與狀態

  • 分享至 

  • xImage
  •  

從一個「1」開始

Day18 到 Day20 做可觀測性的時候,我一直把 Pi 的 session 檔當成「對話存檔」——一份跑完之後可以打開來看的記錄。

直到我寫了一個小工具,把事件重新 fold 成狀態,然後看到這一行:

messages           1

一則。Day22 那次「強制觸發 compaction」的執行,跑了 8 輪、25 次工具呼叫,重建出來的最終狀態只剩一則訊息。

那個「1」最後讓我發現:那個實驗量錯了,而且我的量測程式漏算了一整筆錢。

今天講這個工具,以及為什麼「可重播的記錄」是 harness 最被低估的一個設計。

session 檔不是存檔,是事件記錄

先看它長什麼樣(一行一筆,這裡只留欄位):

{"type": "session",               "id": "01a0e6f2", "version": 3, "cwd": "..."}
{"type": "model_change",          "id": "9777f26a", "parentId": null,     "modelId": "gpt-5.6-luna"}
{"type": "thinking_level_change", "id": "0236e3f8", "parentId": "9777f26a", "thinkingLevel": "medium"}
{"type": "message",               "id": "a10b64b1", "parentId": "0236e3f8", "message": {"role": "user", ...}}
{"type": "message",               "id": "e82e3811", "parentId": "a10b64b1", "message": {"role": "assistant", "usage": {...}}}
{"type": "message",               "id": "bbed593e", "parentId": "e82e3811", "message": {"role": "toolResult", ...}}

三個關鍵性質:

  1. 只追加,不改寫。 沒有任何一筆會被後來的事件修改。
  2. 每筆都有 id 和 parentId。 這不是一條線,是一棵樹——重跑某一輪、編輯某則訊息,是長出一個兄弟節點,不是覆蓋掉原本的。
  3. 「送給模型的對話」不存在於檔案裡。 它是這些事件沿著某一條 parent 鏈 fold 出來的結果。

第三點是整件事的核心:狀態不是被儲存的,是被算出來的。

整個 reducer 就這麼長

def reduce_events(events):
    state = State()
    for event in events:
        kind = event.get("type")
        if kind == "model_change":
            state.model, state.provider = event.get("modelId"), event.get("provider")
        elif kind == "thinking_level_change":
            state.thinking = event.get("thinkingLevel")
        elif kind == "compaction":
            # 摘要取代「下一次請求」要送的歷史;記錄檔裡兩者都留著
            state.compactions.append(event["id"])
            state.messages = [summary_message(event)]
        elif kind == "message":
            state.messages.append(event["message"])
            accumulate_usage(state, event["message"].get("usage"))
    return state

配一個挑分支的函式,就能重建任何一個時間點的狀態:

def chain_to(events, event_id=None):
    """走 parent 鏈,從這棵樹裡挑出一條分支。"""
    by_id = {e["id"]: e for e in events if "id" in e}
    node = by_id.get(event_id) if event_id else events[-1]
    branch = []
    while node is not None:
        branch.append(node)
        node = by_id.get(node.get("parentId"))
    return list(reversed(branch))

就這樣。python -m analysis.session_replay <session.jsonl>:

session format     v3
events in file     24
events on branch   23
events off branch  0
model              openai-codex/gpt-5.6-luna
thinking           medium
messages           21
turns              7
tool calls         13
compactions        0
input tokens       20070
cache read tokens  20480
output tokens      1282
cost usd           0.005962

用法一:驗證量測台沒有說謊

第一件該做的事,是拿 replay 算出來的數字去對量測台當初記下來的數字:

python -m analysis.session_replay <session> --check <run 記錄>
# cross-check vs recorded metrics: {"cost_usd": 0.0, "tool_calls": 0}

差 0。這聽起來很無聊,但它的意思是:我這 30 天貼出來的每一個成本數字,都可以被任何人用這個檔案重新算一次。 那些數字不是我手貼的,是可驗證的。

用法二:時光機

--at <事件 id> 可以重建任意一點的狀態。拿 Day22 那次壓縮來看:

重建到哪 訊息數 輪數 工具呼叫
壓縮之前那一刻 34 8 25
壓縮之後(檔案最後) 1 0 0

壓縮把 34 則訊息換成 1 則摘要——但檔案還是 38 筆事件,一筆都沒有少。

這是 Pi 這個格式做對的地方:壓縮不是「刪掉舊訊息」,而是追加一筆記錄,宣告從這裡開始換一條路走。原始歷史留在檔案裡,事後要稽核「摘要有沒有漏掉重要的東西」,資料都還在。

而正是這個「1」讓我去看事件順序,然後發現壓縮事件永遠是檔案的最後一筆——也就是 Day22 那個「摘要根本沒被用到」的結論。

用法三:不用重跑,只要重算

發現問題之後,真正的麻煩才開始:

我的 metrics.py 漏算了摘要那一次模型呼叫的費用。 它只走 assistant 訊息加總 usage,但壓縮的 usage 掛在 compaction 記錄上。

以前這種事只有一個解法:重跑實驗。160 次執行、大約一小時、加上一筆模型費用,而且重跑出來的數字跟原本那批不能混著比。

但記錄都還在,所以改成這樣:

python -m bench.runner.recompute            # 只報告差異
python -m bench.runner.recompute --write    # 把新的 metrics 寫回 runs.jsonl

輸出:

e22_compaction: 5 run(s) changed
  t5_migration__forced_compaction__r01  compaction_cost_usd: None -> 0.001598,
    cost_usd: 0.006493 -> 0.008091, input_tokens: 19956 -> 24908, total_tokens: 50235 -> 55693
  ... (另外四次)

11 run(s) differ from the recorded metrics.

剩下的 150 次一位數都沒變——這同時證明了修正沒有波及其他實驗。

代價對比很誇張:

重跑 重算
時間 約 1 小時 0.28 秒
成本 約 $1.0 $0
跟舊資料可比嗎 不行(不同時間、不同模型狀態) 就是同一批資料

Day22 那個從 +22%(區間含 0)變成 +45.7%(有把握)的修正,就是這 0.28 秒算出來的。

這件事的一般化版本

把「花了多少錢」這種衍生資料,從記錄裡算出來,不要在記錄的時候就算死。

我當初的量測台把成本算好、存進 runs.jsonl 就算了。如果我當初沒有順手把整份 session 也存下來,今天這個 bug 的修法就只剩重跑。

這 161 份 session、5,848 筆事件,總共只有 9.5 MB。用 9.5 MB 換「所有結論都可以重新推導」,是這整個專案 CP 值最高的一個決定。

順帶一提,這也是 Day26 那個閘門能修好的原因:v2 的測資,是從 session 裡把 agent 真正下過的指令抄出來的。 記錄不只能重算數字,還能變成測試資料。

一個必須講清楚的限制

這是稽核用的 replay,不是執行用的 replay。

它重建的是「狀態」,不是「結果」。模型呼叫不會重跑,所以:

  • ✔ 可以回答:那次執行到底花了多少錢、讀了哪些檔、第幾輪開始繞路。
  • ✘ 不能回答:如果當時 thinking 開高一點,結果會不會不一樣。

後者要的是重新執行,而那就回到 Day7 的量測台:同樣的任務、同樣的條件、再跑一次。兩種 replay 解決的是不同問題,別混為一談。

另外,我這 161 個 session 全部都是直線——沒有一個出現分支。因為非互動 -p 模式跑完就結束,不會有人回頭改某一輪。parentId 的分支能力在我的資料裡沒有被用到,我只能說格式支援它(--resume、fork 都靠它),不能說我驗證過。

如果你要自己做 harness

從這次的踩坑,我會給三個具體建議:

  1. 事件記錄要能單獨還原狀態。 不要把狀態存在別的地方、記錄只當 log——那樣兩邊會不一致,而且你不會知道哪一邊對。
  2. 所有花錢的動作,usage 要放在同一個層級。 我這次的 bug 完全來自「摘要的 usage 在 compaction 記錄上,對話的 usage 在 message 上」。跨層級的欄位遲早會被漏掉。
  3. 衍生指標放在下游。 記錄原始事件,指標用一個可以隨時重跑的程式算。這樣量錯了只是改程式,不是重做實驗。

程式碼:analysis/session_replay.py、bench/runner/recompute.py。

明天

最後一天。Day30 把 30 天量到的東西整理成一份可以直接拿去用的清單,公布完整的資料集,並且老實列出這個系列沒能回答的問題。


上一篇
Day28:把 Coding Agent 放進 Docker:執行成本很低,憑證與網路邊界呢?
下一篇
Day30:30 天 Agent 實驗回顧:哪些 Harness 結論有證據支持?
系列文
Harness Engineering × Pi Agent 實戰:打造可觀測、可評估的 AI Coding Agent 共 30 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言